The purpose of this notebook is to explore different methodologies for constructing a SOFR curve.
import numpy as np
import pandas as pd
import datetime as dt
import plotly.express as px
from plotly.subplots import make_subplots
import copy
import plotly.io as pio
pio.renderers.default = "notebook"
def load_fixings():
sofr_fixings = pd.read_csv("sofr_fixings.csv").iloc[:-1, [0, 2]]
sofr_fixings.columns = ["date", "rate"]
sofr_fixings.rate = sofr_fixings.rate / 100
sofr_fixings.date = sofr_fixings.date.apply(
lambda x: dt.datetime.strptime(x, "%m/%d/%Y").date()
)
sofr_fixings.set_index("date", inplace=True)
sofr_fixings = sofr_fixings.sort_index()
sofr_fixings.index = pd.to_datetime(sofr_fixings.index)
sofr_fixings = sofr_fixings.resample("D").mean().ffill()
return sofr_fixings
1M SOFR Futures have the following features:
class CurveSOFR:
def __init__(self, sofr_fixings: pd.DataFrame):
self.fixings = sofr_fixings.copy()
self.curve = sofr_fixings.copy()
self.current_date = self.fixings.index[-1]
self.current_fix = self.fixings.iloc[-1, 0]
def bootstrap_from_futures(self, fomc_dates: list, future_obj_list: list):
self.curve = self.fixings.copy()
# Not implemented for SOFR3 futures yets
for future in future_obj_list:
print(
f"Bootstrapping rates for SOFR 1M future ending on {future.contract_end}"
)
self.bootstrap_single_rate(fomc_dates, future)
def bootstrap_single_rate(self, fomc_dates: list, future_obj):
start_date = future_obj.contract_start
end_date = future_obj.contract_end
next_fomc = min(
fomc_dates, key=lambda sub: (sub - start_date) < dt.timedelta(0)
)
# Check whether next FOMC meeting falls within contract month, if not, skip
if next_fomc > end_date:
return None
days_to_fomc = future_obj.day_difference(start_date, next_fomc)
days_from_fomc = future_obj.day_difference(next_fomc, end_date) + 1
days_in_contract = future_obj.day_difference(start_date, end_date) + 1
if start_date in self.curve.index:
fix_idx = start_date
else:
fix_idx = self.curve.index[-1]
observed = self.curve[fix_idx:]
r_boot = future_obj.bootstrap_rate_from_px(
observed, days_in_contract, days_to_fomc, days_from_fomc
)
self.curve.loc[next_fomc, "rate"] = r_boot
self.curve.loc[end_date, "rate"] = r_boot
return r_boot
def fill_curve_gaps(self):
self.curve = self.curve.resample("D").mean().ffill()
class Futures:
def __init__(self, contract_start: dt.date, price: float = None):
self.contract_start = contract_start
self.eval_date = None
self.price = price
@property
def implied_rate(self):
if self.price is None:
print(f"Warning: price needed to calculate implied rate")
else:
return (100 - self.price) / 100
class FuturesSOFR1M(Futures):
@property
def contract_end(self):
contract_end = pd.to_datetime(
dt.date(
self.contract_start.year + self.contract_start.month // 12,
self.contract_start.month % 12 + 1,
1,
)
- dt.timedelta(1)
)
return contract_end
def price_from_curve(self, curve_obj: CurveSOFR):
days_in_contract = (self.contract_end - self.contract_start).days + 1
r = (
np.sum(curve_obj.curve.loc[self.contract_start : self.contract_end, "rate"])
/ days_in_contract
)
px = 100 - r * 100
self.price = px
return px
def bootstrap_rate_from_px(
self,
observed: pd.DataFrame,
days_in_contract: int,
days_to_fomc: int,
days_from_fomc: int,
):
r_obs = np.sum(observed.rate) / len(observed)
r_boot = (
self.implied_rate * days_in_contract - r_obs * days_to_fomc
) / days_from_fomc
return r_boot
@staticmethod
def day_difference(date1, date2):
# 1M SOFR is based on calendar days
days = (date2 - date1).days
return days
fomc_dates = [
dt.date(2023, 3, 23),
dt.date(2023, 5, 4),
dt.date(2023, 6, 15),
dt.date(2023, 7, 27),
dt.date(2023, 9, 21),
dt.date(2023, 11, 2),
dt.date(2023, 12, 14),
dt.date(2024, 1, 26),
dt.date(2024, 3, 23),
]
fomc_dates = [pd.to_datetime(x) for x in fomc_dates]
def construct_futures_list_from_csv(fp):
data = pd.read_csv(fp)
data.start_date = pd.DatetimeIndex(data.start_date)
f_list = [FuturesSOFR1M(d, p) for d, p in data.values]
return f_list
futures_list = construct_futures_list_from_csv("1m_futures_data.csv")
sofr_fixings = load_fixings()
sofr_curve = CurveSOFR(sofr_fixings)
sofr_curve.bootstrap_from_futures(fomc_dates, futures_list)
sofr_curve.fill_curve_gaps()
Bootstrapping rates for SOFR 1M future ending on 2023-03-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-04-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-05-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-06-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-07-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-08-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-09-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-10-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-11-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-12-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2024-01-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2024-02-29 00:00:00
The below chart shows our bootstrapped SOFR curve.
However, there is a problem... because we are only allowing SOFR to jump on FOMC meets, for months in which there is no SOFR meeting the price information given by that future is essentially disregarded, as there is no date on which the rate can adjust to meet what the future is implying. Therefore, if we were to use this curve to price those futures, there would be a small pricing error.
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=[
"SOFR Curve: FOMC Jumps only",
"Actual vs bootstap curve implied prices",
],
)
fig_c = px.line(sofr_curve.curve)
# Use our curve to price our futures
actual_prices = np.array([f.price for f in futures_list[:-2]])
futures_list_implied = copy.deepcopy(futures_list)
implied_prices = np.array(
[f.price_from_curve(sofr_curve) for f in futures_list_implied[:-2]]
)
pricing_errors = actual_prices - implied_prices
dates = [f.contract_start for f in futures_list[:-2]]
fig_e = px.scatter(
x=dates,
y=[implied_prices, actual_prices],
template="plotly_dark",
symbol_sequence=["circle-open"],
)
fig.add_trace(
fig_c.data[0], row=1, col=1,
)
fig.add_trace(
fig_e.data[0], row=1, col=2,
)
fig.add_trace(
fig_e.data[1], row=1, col=2,
)
fig.update_layout(
showlegend=False, xaxis_title=None, yaxis_title="S0FR"
)
fig.show()
Although SOFR can reasonably be expected to jump by the amount of a fed hike following a FOMC meeting, it can still fix in a range on a daily basis. In particular month ends seem to be dates on which SOFR jumps small amounts. Therefore, I am loosening the assumption that SOFR can only jump on FOMC dates, and allowing it to also jump at the start of the month, for months in which there is no FOMC meet.
fomc_dates = [
dt.date(2023, 3, 23),
dt.date(2023, 5, 4),
dt.date(2023, 6, 15),
dt.date(2023, 7, 27),
dt.date(2023, 9, 21),
dt.date(2023, 11, 2),
dt.date(2023, 12, 14),
dt.date(2024, 1, 26),
dt.date(2024, 3, 23),
]
additional_jump_dates = [
dt.date(2023, 4, 1),
dt.date(2023, 8, 1),
dt.date(2023, 10, 1),
dt.date(2023, 12, 31),
]
jump_dates = fomc_dates + additional_jump_dates
jump_dates = [pd.to_datetime(x) for x in jump_dates]
jump_dates.sort()
futures_list = construct_futures_list_from_csv("1m_futures_data.csv")
sofr_fixings = load_fixings()
sofr_curve = CurveSOFR(sofr_fixings)
sofr_curve.bootstrap_from_futures(jump_dates, futures_list)
sofr_curve.fill_curve_gaps()
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=[
"SOFR Curve: FOMC + additional jumps",
"Actual vs bootstap curve implied prices",
],
)
fig_c = px.line(sofr_curve.curve)
# Use our curve to price our futures
actual_prices = np.array([f.price for f in futures_list[:-2]])
futures_list_implied = copy.deepcopy(futures_list)
implied_prices = np.array(
[f.price_from_curve(sofr_curve) for f in futures_list_implied[:-2]]
)
pricing_errors = actual_prices - implied_prices
dates = [f.contract_start for f in futures_list[:-2]]
fig_e = px.scatter(
x=dates,
y=[implied_prices, actual_prices],
template="plotly_dark",
symbol_sequence=["circle-open"],
)
fig.add_trace(
fig_c.data[0], row=1, col=1,
)
fig.add_trace(
fig_e.data[0], row=1, col=2,
)
fig.add_trace(
fig_e.data[1], row=1, col=2,
)
fig.update_layout(
showlegend=False, xaxis_title=None, yaxis_title="S0FR", template="plotly_dark"
)
fig.show()
Bootstrapping rates for SOFR 1M future ending on 2023-03-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-04-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-05-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-06-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-07-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-08-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-09-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-10-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-11-30 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2023-12-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2024-01-31 00:00:00 Bootstrapping rates for SOFR 1M future ending on 2024-02-29 00:00:00
class FuturesSOFR3M(Futures):
@property
def contract_end(self):
# End 1 day before third wednesday of third month
contract_end = pd.date_range(
self.contract_start,
self.contract_start + pd.DateOffset(months=3) + pd.offsets.MonthEnd(1),
freq="WOM-3WED",
)[-1] + pd.DateOffset(days=-1)
return contract_end
@staticmethod
def day_difference(date1, date2):
# 3M SOFR is based on business days however since we take the ffil our fixings this doesn't matter and we can use calendar days
days = (date2 - date1).days
return days
def bootstrap_rate_from_px(
self,
observed: pd.DataFrame,
days_in_contract: int,
days_to_fomc: int,
days_from_fomc: int,
):
# Lets assume latest observed rate the holds until the next key date
observed_rates = np.array(observed.rate)
observed_rates = np.append(
observed_rates,
np.array([observed.rate[-1]] * (days_to_fomc - len(observed.rate))),
)
r_obs = np.prod(1 + observed_rates / 360)
r_numerator = 1 + self.implied_rate * days_in_contract / 360
r_boot = ((r_numerator / r_obs) ** (1 / days_from_fomc) - 1) * 360
return r_boot
def price_from_curve(self, curve_obj: CurveSOFR):
days_in_contract = (self.contract_end - self.contract_start).days + 1
r = (
np.prod(
1
+ curve_obj.curve.loc[self.contract_start : self.contract_end, "rate"]
/ 360
)
- 1
) * (360 / days_in_contract)
px = 100 - r * 100
self.price = px
return px
sofr = CurveSOFR(sofr_fixings)
sofr_3m = FuturesSOFR3M(pd.to_datetime(dt.date(2023, 1, 18)), 95.3725)
sofr.bootstrap_single_rate(jump_dates, sofr_3m)
sofr.fill_curve_gaps()
sofr.curve
| rate | |
|---|---|
| date | |
| 2023-01-03 | 0.043100 |
| 2023-01-04 | 0.043000 |
| 2023-01-05 | 0.043100 |
| 2023-01-06 | 0.043100 |
| 2023-01-07 | 0.043100 |
| ... | ... |
| 2023-04-14 | 0.048587 |
| 2023-04-15 | 0.048587 |
| 2023-04-16 | 0.048587 |
| 2023-04-17 | 0.048587 |
| 2023-04-18 | 0.048587 |
106 rows × 1 columns